Skip to content

Refactor: move per-realm advisory lock into DBAdapter.withWriteLock#4839

Merged
lukemelia merged 1 commit into
mainfrom
withwritelock-refactor
May 14, 2026
Merged

Refactor: move per-realm advisory lock into DBAdapter.withWriteLock#4839
lukemelia merged 1 commit into
mainfrom
withwritelock-refactor

Conversation

@lukemelia
Copy link
Copy Markdown
Contributor

Summary

  • Promote the per-realm advisory lock primitive from a standalone helper in packages/realm-server/lib/ to a withWriteLock method on the DBAdapter interface, with the real Postgres implementation living in @cardstack/postgres.
  • This keeps the crypto + pg_advisory_xact_lock plumbing Node-only (it never reaches the browser bundle), and unblocks the next PR (CS-11125) from calling the lock from packages/runtime-common/realm.ts.

Behavior is unchanged — same advisory lock semantics, same callers, same CS-10898 pinned-txQuerier transactional cleanup contract.

What's in

  • packages/runtime-common/db.ts — adds withWriteLock(realmUrl, fn(txQuerier)) to the DBAdapter interface.
  • packages/postgres/pg-adapter.ts — implements on PgAdapter and re-exports hashRealmUrlForAdvisoryLock as the canonical hash helper.
  • packages/host/app/lib/sqlite-adapter.ts — passthrough (no cross-connection concurrency to coordinate; txQuerier is undefined).
  • packages/realm-server/{server.ts, handlers/handle-{publish,unpublish,delete}-realm.ts} — callers use dbAdapter.withWriteLock(...) directly.
  • packages/realm-server/lib/realm-advisory-locks.ts — deleted.
  • Tests: realm-advisory-locks-test, realm-cleanup-transaction-test updated to the method form. Inline DBAdapter test stubs in indexing-event-sink-test, prerender-proxy-test, screenshot-card-test get a passthrough withWriteLock.

Test plan

  • pnpm test-module TEST_MODULE="realm-advisory-locks-test.ts" — 7/7 pass after the rename.
  • pnpm test-module TEST_MODULE="realm-cleanup-transaction-test.ts" — 3/3 pass; confirms CS-10898 transactional behavior survives the method form.
  • pnpm exec tsc -p packages/runtime-common --noEmit and -p packages/postgres, -p packages/realm-server, -p packages/host — zero new type errors vs. main.
  • CI run of the full realm-server suite once this PR is open.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the per-realm Postgres advisory write-lock from a realm-server-local helper into the shared DBAdapter interface, implemented by PgAdapter (real lock + pinned transaction) and a SQLite passthrough. This is intended to keep Node-only plumbing out of browser bundles and make the lock callable from shared runtime-common code.

Changes:

  • Add DBAdapter.withWriteLock(realmUrl, fn) to packages/runtime-common/db.ts and update realm-server callers to use it.
  • Implement withWriteLock (and export hashRealmUrlForAdvisoryLock) in packages/postgres/pg-adapter.ts; add SQLite passthrough implementation.
  • Remove packages/realm-server/lib/realm-advisory-locks.ts and update tests + local DBAdapter stubs accordingly.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/runtime-common/db.ts Extends DBAdapter interface with withWriteLock callback API.
packages/postgres/pg-adapter.ts Implements advisory-lock + transaction-scoped lock acquisition; exports canonical realm-url hash helper.
packages/host/app/lib/sqlite-adapter.ts Adds SQLite passthrough withWriteLock implementation.
packages/realm-server/server.ts Switches realm creation to use dbAdapter.withWriteLock.
packages/realm-server/handlers/handle-publish-realm.ts Switches publish flow to dbAdapter.withWriteLock (but currently drops prior cache invalidation step; see review comment).
packages/realm-server/handlers/handle-unpublish-realm.ts Switches unpublish cleanup to dbAdapter.withWriteLock.
packages/realm-server/handlers/handle-delete-realm.ts Switches delete cleanup to dbAdapter.withWriteLock.
packages/realm-server/lib/realm-advisory-locks.ts Deletes the old helper module.
packages/realm-server/tests/realm-advisory-locks-test.ts Updates tests to call PgAdapter.withWriteLock and import hash helper from @cardstack/postgres.
packages/realm-server/tests/realm-cleanup-transaction-test.ts Updates regression test to call dbAdapter.withWriteLock.
packages/realm-server/tests/indexing-event-sink-test.ts Adds withWriteLock to inline adapter stubs.
packages/realm-server/tests/prerender-proxy-test.ts Adds withWriteLock to inline adapter stub.
packages/realm-server/tests/screenshot-card-test.ts Adds withWriteLock to inline adapter stub.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/realm-server/handlers/handle-publish-realm.ts
Comment thread packages/host/app/lib/sqlite-adapter.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 14, 2026

Preview deployments

Host Test Results

    1 files      1 suites   1h 59m 20s ⏱️
2 658 tests 2 643 ✅ 15 💤 0 ❌
2 677 runs  2 662 ✅ 15 💤 0 ❌

Results for commit 87eb4d8.

Realm Server Test Results

    1 files  ±  0      1 suites  ±0   8m 3s ⏱️ + 1m 25s
1 374 tests +238  1 374 ✅ +239  0 💤 ±0  0 ❌  - 1 
1 455 runs  +253  1 455 ✅ +254  0 💤 ±0  0 ❌  - 1 

Results for commit 87eb4d8. ± Comparison against earlier commit 450e521.

`withRealmWriteLock` previously lived in `packages/realm-server/lib/`
and was only reachable from realm-server code. Moving the lock primitive
onto the `DBAdapter` interface as a `withWriteLock` method keeps the
implementation (and its `crypto` + `pg_advisory_xact_lock` plumbing) in
@cardstack/postgres while letting any DBAdapter consumer call it through
the existing interface — including code in runtime-common that ships to
the browser, which previously couldn't import the helper without leaking
Node-only crypto into the browser bundle.

- packages/runtime-common/db.ts: add required `withWriteLock` method to
  the `DBAdapter` interface.
- packages/postgres/pg-adapter.ts: implement on PgAdapter. Move
  `hashRealmUrlForAdvisoryLock` here as the canonical hash helper.
- packages/host/app/lib/sqlite-adapter.ts: no-op passthrough — SQLite has
  no cross-connection concurrency to coordinate.
- packages/realm-server: callers (`server.ts`, publish/unpublish/delete
  handlers, tests) call `dbAdapter.withWriteLock(url, fn)` directly; the
  old `realm-advisory-locks.ts` is deleted. Tests that build inline
  DBAdapter stubs gain a passthrough `withWriteLock`.

No behavior change. Same advisory lock semantics, same callers, same
transactional cleanup contract for CS-10898 (the pinned txQuerier is
unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia force-pushed the withwritelock-refactor branch from 450e521 to 87eb4d8 Compare May 14, 2026 19:48
lukemelia added a commit that referenced this pull request May 14, 2026
The advisory lock from CS-10891 wrapped only the realm-lifecycle handlers
(create / publish / unpublish / delete realm). The data-plane mutation
routes that normal client traffic actually hits (POST/PATCH/DELETE
<realm>/<path>, POST <realm>/_atomic) didn't take it — fine on a single
replica because the Node event loop is the implicit serializer, but a
real lost-update race the moment a second replica appears behind an LB.

This PR threads the per-realm `withWriteLock` (newly exposed on
`DBAdapter` by the parent refactor) through the data-plane mutations in
`packages/runtime-common/realm.ts`:

- `write` / `writeMany` / `delete` / `deleteAll` wrap their inner work
  in the lock. Inner methods are renamed `_batchWriteUnlocked` /
  `_deleteUnlocked` / `_deleteAllUnlocked` so callers that need the
  lock around a wider critical section can re-use the inner without
  re-entering the lock (which would block on a different pinned pool
  connection).
- `handleAtomicOperations` takes the lock at the handler boundary so
  the `add`/`update` precheck is in the same critical section as the
  writeMany — fixes the `/_atomic` TOCTOU spelled out in the ticket.
- `patchCardInstance` takes the lock around the indexEntry read +
  merge + write, so two concurrent PATCHes from different replicas
  can't both read the same `original` and clobber each other's merge.

Lock granularity stays per-realm — concurrent writes to different
realms remain fully parallel. SQLite (host) is a passthrough (no
cross-connection concurrency to coordinate), so this is a no-op there.

## Regression test

`packages/realm-server/tests/data-plane-write-lock-test.ts`:
1. Two concurrent PATCHes against the same card with non-overlapping
   attribute changes — both must persist. Without the lock the second
   writer would compute its merge from pre-first state and drop the
   first writer's field.
2. Two concurrent `/_atomic` `add` operations on the same href — must
   yield 201 + 409, not 201 + 201. Without the lock both pass the
   precheck and both write.

Compatible with single-replica deploy: the lock is a no-op without
contention. Lands before CS-10899 (atomicity follow-up) and the
cache-invalidation tickets that assume writes are already serialized.

Stacks on #4839 (DBAdapter.withWriteLock refactor).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukemelia lukemelia requested review from a team and backspace May 14, 2026 21:31
@habdelra
Copy link
Copy Markdown
Contributor

should create realm also be using this lock?

@lukemelia lukemelia merged commit 94515b8 into main May 14, 2026
74 of 75 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants